Cada proyecto web se enfrenta tarde o temprano a la misma decisión: ¿guardo el estado de autenticación en el cliente (cookies con datos o tokens) o en el servidor (sesiones con un ID opaco)? La respuesta afecta a seguridad, rendimiento, escalabilidad, experiencia de usuario y a cómo depuras incidencias en producción.
Este artÃculo explica, con enfoque de programación y arquitectura, qué gana y qué pierde cada enfoque, cómo protegerlos frente a XSS/CSRF, qué hacer con JWT, y cómo integrarlo en frameworks populares (Express, Django, Rails, Spring, Laravel).
Qué almacena quién (y por qué te importa)
El servidor envÃa Set-Cookie y el navegador reenvÃa la cookie en cada petición. La cookie puede contener datos (p. ej., preferencias) o un token firmado(tÃpicamente un JWT) que representa el estado del usuario. El backend nonecesita memoria adicional: valida el token y continúa.
El backend crea una entrada en un session store (memoria, Redis, base de datos). El navegador solo guarda un identificador opaco (session id) en una cookie. En cada request, el servidor busca la sesión y recupera el estado.
Consecuencia directa: cookies con datos ? stateless (ideal para edge/serverless); sesiones ? stateful (necesitas un store compartido para escalar).
Ventajas, riesgos y costes
Cookies con datos o tokens (p. ej., JWT)
Pros
Contras
Cuándo encaja
APIs/microservicios stateless, distribución CDN/edge, autenticación B2C a gran escala con refresco a corto plazo y mecanismos de revocación bien diseñados.
Sesiones (ID opaco + store)
Pros
Contras
Cuándo encaja
Aplicaciones web clásicas con panel, roles y polÃticas de cumplimiento (PII/GDPR); intranets; B2B; back-offices.
Seguridad: checklist práctico
Siempre en el cookie del navegador
Set-Cookie: sid=...; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=1800
Contra XSS
Contra CSRF
Rotación de credenciales
Revocación
Patrón A: sesión en servidor con Redis (Express)
import express from 'express'
import session from 'express-session'
import RedisStore from 'connect-redis'
import { createClient } from 'redis'
const app = express()
const redis = createClient({ url: process.env.REDIS_URL })
await redis.connect()
app.use(session({
store: new RedisStore({ client: redis }),
secret: process.env.SESSION_SECRET,
name: 'sid',
resave: false,
saveUninitialized: false,
rolling: true, // refresca expiración en actividad
cookie: {
httpOnly: true,
secure: true, // true en HTTPS
sameSite: 'lax',
maxAge: 30 * 60 * 1000
}
}))
app.post('/login', async (req, res) => {
// ... validar credenciales
req.session.regenerate(err => { // rotar id (mitiga fijación)
if (err) return res.sendStatus(500)
req.session.userId = user.id
res.sendStatus(204)
})
})
app.post('/logout', (req, res) => {
req.session.destroy(() => res.clearCookie('sid').sendStatus(204))
})
Patrón B: JWT en cookie HttpOnly (API stateless)
// emitir JWT corto + refresh opaco
const access = sign({ sub: user.id, scope }, ACCESS_SECRET, { expiresIn: '10m' })
const refresh = crypto.randomUUID()
await redis.set(`refresh:${refresh}`, user.id, { EX: 60*60*24*7 }) // 7 dÃas
res.setHeader('Set-Cookie', [
cookie('access', access, { httpOnly: true, secure: true, sameSite: 'Lax', path: '/' }),
cookie('refresh', refresh,{ httpOnly: true, secure: true, sameSite: 'Strict', path: '/auth' }),
])
res.sendStatus(204)
const token = req.cookies.access
try {
req.user = verify(token, ACCESS_SECRET) // validación local (sin store)
return next()
} catch {
return res.sendStatus(401)
}
const r = req.cookies.refresh
const uid = await redis.getDel(`refresh:${r}`) // úsalo una sola vez (rotation)
if (!uid) return res.sendStatus(401)
// emitir nuevos tokens y guardar refresh nuevo en Redis
? Usa cookies HttpOnly; localStorage es accesible al JS (XSS = adiós sesión).
? session.regenerate() / re-emitir token; evita fijación.
? Rechazado por navegadores modernos; siempre Secure en producción.
? exp corto (5?15 min) + refresh token rotatorio + lista de revocación.
? Añade ver y maneja compatibilidad en validación.
? Define TTL y polÃticas de expiración; monitoriza cardinalidad/latencias.
Integración por frameworkÂ
Decisión rápida (árbol mental)
? Sesión server-side (ID opaco + Redis) + rotación de id.
? Cookies de clave/valor especÃficas (sin datos privados).
? Minimiza datos en el cliente; sesiones y cifrado en reposo del store.
Snippets de referencia
Set-Cookie: sid=3f5f2a...; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=1800
<input type="hidden" name="csrf" value="{{csrfToken}}">
// servidor
assert(req.body.csrf === req.session.csrf)Â
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{{n}}'; object-src 'none'; base-uri 'self'Â
ConclusiónÂ
Preguntas frecuentes (FAQ)
¿Es seguro usar JWT en cookies?
SÃ, si las marcas HttpOnly + Secure + SameSite, pones expiración corta, rotas y controlas el refresh con revocación. Evita localStorage para tokens de sesión.
¿Puedo mezclar sesiones y JWT?
SÃ. Patrón común: sesión server-side para el panel web y JWT stateless para APIs/apps móviles. Alinea expiraciones y polÃticas de revocación.
¿Cómo cierro sesión ?al instante? con JWT?
Mantén refresh tokens en Redis (u otro store) y rotación por uso. Al cerrar sesión, invalida el refresh (bloqueo) y haz expirar el access token en minutos.
¿SameSite=Lax es suficiente contra CSRF?
Para la mayorÃa de formularios sÃ, pero en operaciones crÃticas usa token CSRFadicional o SameSite=Strict si la UX lo permite.
